本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前文摘要
从之前的三篇文章中,我们打通了Componet到Element以及RenderNode之间的关系,通过学习这些知识,相信读者能够学习任意一个组件的内部知识。
今天的内容是命中测试,通过学习本篇,我们将明白ArkUI中是如何进行事件的分发,同时我们将学习一个基础类ClickRecognizer,它是engine中点击事件非常重要的基础类。
TouchTest
TouchTest是RenderNode中非常重要的方法,我们前面提到过,RenderNode是最终负责提交给pipeline形成Layer的最后一块单元,它不仅包含着渲染逻辑的驱动,也包含着点击事件的识别。
ini
bool RenderNode::TouchTest(const Point& globalPoint, const Point& parentLocalPoint, const TouchRestrict& touchRestrict,
TouchTestResult& result)
{
if (disableTouchEvent_ || disabled_) {
return false;
}
Point transformPoint = GetTransformPoint(parentLocalPoint);
if (!InTouchRectList(transformPoint, GetTouchRectList())) {
return false;
}
构造本地坐标
const auto localPoint = transformPoint - GetPaintRect().GetOffset();
bool dispatchSuccess = DispatchTouchTestToChildren(localPoint, globalPoint, touchRestrict, result);
auto beforeSize = result.size();
std::vector<Rect> vrect;
if (IsResponseRegion()) {
vrect = responseRegionList_;
}
vrect.emplace_back(paintRect_);
for (const auto& rect : vrect) {
if (touchable_ && rect.IsInRegion(transformPoint)) {
// Calculates the coordinate offset in this node.
globalPoint_ = globalPoint;
const auto coordinateOffset = globalPoint - localPoint;
coordinatePoint_ = Point(coordinateOffset.GetX(), coordinateOffset.GetY());
// OnTouchTestHit 这个方法很重要
OnTouchTestHit(coordinateOffset, touchRestrict, result);
break;
}
}
auto endSize = result.size();
return dispatchSuccess || (beforeSize != endSize && IsNotSiblingAddRecognizerToResult());
}
当点击触发时,window会负责把点击事件进行分发,这个时候每一个RenderNode需要做的事情如下:
-
判断自己本身是否能够响应点击事件
-
分发点击事件给子RenderNode
-
判断自身是否需要响应(即加入TouchTestResult)
本身是否能够响应点击事件
RenderNode判断自身能不能响应点击,其实就是上面这两步
kotlin
if (disableTouchEvent_ || disabled_) {
return false;
}
Point transformPoint = GetTransformPoint(parentLocalPoint);
if (!InTouchRectList(transformPoint, GetTouchRectList())) {
return false;
}
如果自身被设置为不可点击状态,那么自然就不能进行事件的响应,同时还要根据父RenderNode的坐标计算相对于父RenderNode的坐标,来判断自身的可点击区域是否在本次点击事件之内
如果满足了自身可点击且在点击事件范围内,那么就可以进行下一步。
分发事件
分发事件的过程跟一般View系统的分发过程一样,首先按照Z轴方向进行排列,这是因为最先被看到的子控件当然最先有响应的权限。
ini
bool RenderNode::DispatchTouchTestToChildren(
const Point& localPoint, const Point& globalPoint, const TouchRestrict& touchRestrict, TouchTestResult& result)
{
bool dispatchSuccess = false;
if (!IsChildrenTouchEnable() || GetHitTestMode() == HitTestMode::HTMBLOCK) {
return dispatchSuccess;
}
根据Z轴进行排序
const auto& sortedChildren = SortChildrenByZIndex(GetChildren());
for (auto iter = sortedChildren.rbegin(); iter != sortedChildren.rend(); ++iter) {
const auto& child = *iter;
if (!child->GetVisible() || child->disabled_ || child->disableTouchEvent_) {
continue;
}
调用TouchTest
if (child->TouchTest(globalPoint, localPoint, touchRestrict, result)) {
dispatchSuccess = true;
if (child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT) {
break;
}
}
auto interceptTouchEvent =
(child->IsTouchable() && (child->InterceptTouchEvent() || IsExclusiveEventForChild()) &&
child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT);
if (child->GetHitTestMode() == HitTestMode::HTMBLOCK || interceptTouchEvent) {
auto localTransformPoint = child->GetTransformPoint(localPoint);
bool isInRegion = false;
for (const auto& rect : child->GetTouchRectList()) {
if (rect.IsInRegion(localTransformPoint)) {
dispatchSuccess = true;
isInRegion = true;
break;
}
}
if (isInRegion && child->GetHitTestMode() != HitTestMode::HTMDEFAULT) {
break;
}
}
}
return dispatchSuccess;
}
父RenderNode的TouchTest方法中,会进行遍历调用每个子RenderNode的TouchTest方法,其实就是按照广度优先的方式执行每一个RenderNode的TouchTest方法
有意思的是,RenderNode的遍历也有着AndroidViewGroup的intercept机制,即子RenderNode可以设置自己按照某些情况不接受点击事件,即设置自己的HitTestMode,这里可以用于点击事件的冲突
scss
auto interceptTouchEvent =
(child->IsTouchable() && (child->InterceptTouchEvent() || IsExclusiveEventForChild()) &&
child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT);
按照功能不同,HitTestMode 根据是否屏蔽事件分发,以及自身或者子RenderNode能响应的范围,分为以下4种
c
enum class HitTestMode {
/**
* Both self and children respond to the hit test for touch events,
* but block hit test of the other nodes which is masked by this node.
*/
HTMDEFAULT = 0,
/**
* Self respond to the hit test for touch events,
* but block hit test of children and other nodes which is masked by this node.
*/
HTMBLOCK,
/**
* Self and child respond to the hit test for touch events,
* and allow hit test of other nodes which is masked by this node.
*/
HTMTRANSPARENT,
/**
* Self not respond to the hit test for touch events,
* but children respond to the hit test for touch events.
*/
HTMNONE
};
同样的,当控件决定自行处理并不再分发事件时,比如mode为HTMBLOCK,此时还会进行补充校验,即判断自身是否能够响应本次的点击:即判断x,y是否能在自己的宽度与高度范围之内
自身x <= 点击事件 x坐标 <= 自身x+width
自身y<= 点击事件 y坐标 <= 自身y+height
arduino
bool IsInRegion(const Point& point) const
{
return (point.GetX() >= x_) && (point.GetX() < (x_ + width_)) && (point.GetY() >= y_) &&
(point.GetY() < (y_ + height_));
}
是否加入命中测试结果
当满足前几个条件之后,那么RenderNode就可以有"资格"进行事件响应了,这里说有"资格",是因为调用OnTouchTestHit后,控件可以选择把自己的点击加入到TouchTestResult,我们来认识一下这个方法
arduino
virtual void OnTouchTestHit(
const Offset& coordinateOffset, const TouchRestrict& touchRestrict, TouchTestResult& result)
{}
OnTouchTestHit最关键的是第三个参数TouchTestResult类型的result,它其实是一个list
ini
using TouchTestResult = std::list<RefPtr<TouchEventTarget>>;
这里如果选择加入命中测试的话,那么当前RenderNode会构建一个TouchEventTarget加入到TouchTestResult中,然后再由TouchEventTarget发起回调。
在ArkUI中,有一个经常被使用的TouchEventTarget实现类,它就是ClickRecognizer,用于封装了点击事件的回调以及相关流程调用处理
kotlin
class ClickRecognizer : public MultiFingersRecognizer {
DECLARE_ACE_TYPE(ClickRecognizer, MultiFingersRecognizer);
ClickRecognizer有很多有用的基础封装,比如SetOnClick设立一个点击事件,方便后续回调,还有其他接受点击事件以及拒绝的封装
csharp
void OnAccepted() override;
void OnRejected() override;
void SetOnClick(const ClickCallback& onClick)
{
onClick_ = onClick;
}
就拿官方代码举个例子,如果我们想要写一个ArkUI控件的点击事件,我们只需要3步:
1、创建一个ClickRecognizer
;
2、重写OnTouchTestHit
函数,注册RenderMyCircle
的ClickRecognizer
,这样在接收到点击事件时即可触发创建ClickRecognizer
时添加的事件回调;
3、实现在接收到点击事件之后的处理逻辑HandleMyCircleClickEvent
scss
RenderMyCircle::RenderMyCircle()
{
clickRecognizer_ = AceType::MakeRefPtr<ClickRecognizer>();
clickRecognizer_->SetOnClick([wp = WeakClaim(this)](const ClickInfo& info) {
auto myCircle = wp.Upgrade();
if (!myCircle) {
LOGE("WeakPtr of RenderMyCircle fails to be upgraded, stop handling click event.");
return;
}
调用自定义的回调,比如回调到js
myCircle->HandleMyCircleClickEvent(info);
});
}
void RenderMyCircle::OnTouchTestHit(
const Offset& coordinateOffset, const TouchRestrict& touchRestrict, TouchTestResult& result)
{
clickRecognizer_->SetCoordinateOffset(coordinateOffset);
关键在于通过result加入clickRecognizer_
result.emplace_back(clickRecognizer_);
}
void RenderMyCircle::HandleMyCircleClickEvent(const ClickInfo& info)
{
if (callbackForJS_) {
auto result = std::string(""circleclick",{"radius":")
.append(std::to_string(NormalizeToPx(circleRadius_)))
.append(","edgewidth":")
.append(std::to_string(NormalizeToPx(edgeWidth_)))
.append("}");
callbackForJS_(result);
}
}
回到正题,OnTouchTestHit 是一个虚方法,因此每个实现类都会根据自身的一些特性去进行重写,最终命中测试的结果会被加入result这个list即可,比如Text控件对应的RenderText
scss
if (needClickDetector_) {
if (!clickDetector_) {
clickDetector_ = AceType::MakeRefPtr<ClickRecognizer>();
clickDetector_->SetOnClick([weak = WeakClaim(this)](const ClickInfo& info) {
auto text = weak.Upgrade();
if (text) {
text->HandleClick(info);
}
});
clickDetector_->SetRemoteMessage([weak = WeakClaim(this)](const ClickInfo& info) {
auto text = weak.Upgrade();
if (text) {
text->HandleRemoteMessage(info);
}
});
}
clickDetector_->SetCoordinateOffset(coordinateOffset);
clickDetector_->SetTouchRestrict(touchRestrict);
clickDetector_->SetIsExternalGesture(true);
result.emplace_back(clickDetector_);
....
当所有TouchEventTarget事件被收集后,后续的事件就可以被继续分发了
arduino
class ACE_EXPORT TouchEventTarget : public virtual AceType {
DECLARE_ACE_TYPE(TouchEventTarget, AceType);
public:
TouchEventTarget() = default;
TouchEventTarget(std::string nodeName, int32_t nodeId) : nodeName_(std::move(nodeName)), nodeId_(nodeId) {}
~TouchEventTarget() override = default;
// if return false means need to stop event dispatch.
virtual bool DispatchEvent(const TouchEvent& point) = 0;
// if return false means need to stop event bubbling.
virtual bool HandleEvent(const TouchEvent& point) = 0;
virtual bool HandleEvent(const AxisEvent& event)
{
ClickRecognizer
的父类NGGestureRecognizer就会重写HandleEvent方法,用于MOVE - UP这些其他事件的分发
arduino
bool NGGestureRecognizer::HandleEvent(const TouchEvent& point)
{
if (!ShouldResponse()) {
return true;
}
switch (point.type) {
case TouchType::MOVE:
HandleTouchMoveEvent(point);
break;
case TouchType::DOWN: {
deviceId_ = point.deviceId;
deviceType_ = point.sourceType;
auto result = AboutToAddCurrentFingers(point.id);
if (result) {
HandleTouchDownEvent(point);
}
break;
}
case TouchType::UP:
HandleTouchUpEvent(point);
currentFingers_--;
break;
case TouchType::CANCEL:
HandleTouchCancelEvent(point);
currentFingers_--;
break;
default:
break;
}
return true;
}
ArkUI点击事件流程
ArkUI点击事件流程包括:绑定过程与触发过程,通过FocusNode这个类贯穿全体
绑定过程
在ArkUI中,我们可以对组件添加点击事件,如下:
javascript
Row.onClick(() => {
this.x += 10
})
以Row容器举例子,onClick的ts方法最终会被映射成JsOnClick方法的执行,这个方法主要是把我们自定的箭头函数,比如上文的=>xxx这个函数进行C++层的回调注册
scss
void JSInteractableView::JsOnClick(const JSCallbackInfo& info)
{
JSRef<JSVal> jsOnClickVal = info[0];
if (jsOnClickVal->IsUndefined() && IsDisableEventVersion()) {
ViewAbstractModel::GetInstance()->DisableOnClick();
return;
}
if (!jsOnClickVal->IsFunction()) {
return;
}
WeakPtr<NG::FrameNode> frameNode = NG::ViewStackProcessor::GetInstance()->GetMainFrameNode();
auto jsOnClickFunc = AceType::MakeRefPtr<JsClickFunction>(JSRef<JSFunc>::Cast(info[0]));
auto onTap = [execCtx = info.GetExecutionContext(), func = jsOnClickFunc, node = frameNode](GestureEvent& info) {
JAVASCRIPT_EXECUTION_SCOPE_WITH_CHECK(execCtx);
ACE_SCORING_EVENT("onClick");
PipelineContext::SetCallBackNode(node);
func->Execute(info);
};
auto onClick = [execCtx = info.GetExecutionContext(), func = jsOnClickFunc, node = frameNode](
const ClickInfo* info) {
JAVASCRIPT_EXECUTION_SCOPE_WITH_CHECK(execCtx);
ACE_SCORING_EVENT("onClick");
PipelineContext::SetCallBackNode(node);
func->Execute(*info);
};
// 注册点击事件
ViewAbstractModel::GetInstance()->SetOnClick(std::move(onTap), std::move(onClick));
auto focusHub = NG::ViewStackProcessor::GetInstance()->GetOrCreateMainFrameNodeFocusHub();
CHECK_NULL_VOID(focusHub);
focusHub->SetFocusable(true, false);
}
onClick变量中包含着当发生点击事件时需要触发的函数jsOnClickFunc,还有当前的执行环境以及上文说到的ClickInfo点击事件信息等。最后通过ViewAbstractModel的SetOnClick进行注册,ViewAbstractModel默认实现是ViewAbstractModelImpl
scss
void ViewAbstractModelImpl::SetOnClick(GestureEventFunc&& tapEventFunc, ClickEventFunc&& clickEventFunc)
{
auto inspector = ViewStackProcessor::GetInstance()->GetInspectorComposedComponent();
CHECK_NULL_VOID(inspector);
auto impl = inspector->GetInspectorFunctionImpl();
RefPtr<Gesture> tapGesture = AceType::MakeRefPtr<TapGesture>(1, 1);
tapGesture->SetOnActionId([func = std::move(tapEventFunc), impl](GestureEvent& info) {
if (impl) {
impl->UpdateEventInfo(info);
}
func(info);
});
auto click = ViewStackProcessor::GetInstance()->GetBoxComponent();
click->SetOnClick(tapGesture);
auto onClickId = EventMarker([func = std::move(clickEventFunc), impl](const BaseEventInfo* info) {
const auto* clickInfo = TypeInfoHelper::DynamicCast<ClickInfo>(info);
if (!clickInfo) {
return;
}
auto newInfo = *clickInfo;
if (impl) {
impl->UpdateEventInfo(newInfo);
}
func(clickInfo);
});
// 拿的是Focusable 组件
auto focusableComponent = ViewStackProcessor::GetInstance()->GetFocusableComponent(false);
if (focusableComponent) {
focusableComponent->SetOnClickId(onClickId);
}
}
点击事件生效前提是组件一定是可获取焦点的组件,比如我们总不可能在IF 这个组件里面添加点击事件。(鸿蒙的if foreach 最终都会被映射成对应的功能组件,没有switch组件因此我们不能用switch去涵盖内容控件的添加),至此完成了Component与点击事件的绑定。
当FocusableComponent完成绑定后,之后的FocusableElement会调用Update方法时同步对应的onClickId,之后通过FocusNode的SetOnClickCallback绑定了FocusNode 与点击事件 (本质就是绑定FocusableElement与点击事件)
scss
void FocusableElement::Update()
{
UpdateAccessibilityNode();
auto focusableComponent = DynamicCast<FocusableComponent>(component_);
if (!focusableComponent) {
LOGE("Can not dynamicCast to focusableComponent!");
return;
}
......
if (!onClickId.IsEmpty()) {
auto context = context_.Upgrade();
if (context) {
ArkUI 会命中这里,
if (context->GetIsDeclarative()) {
SetOnClickCallback(AceAsyncEvent<void(const std::shared_ptr<ClickInfo>&)>::Create(onClickId, context_));
} else {
SetOnClickCallback(AceAsyncEvent<void()>::Create(onClickId, context_));
}
}
}
这里我们要注意一下,FocusNode只是一个代表具备聚焦能力的类,区别于RenderNode,大家不要搞混了,FocusableElement继承了FocusGroup,FocusGroup继承了FocusNode,FocusableElement本质也是FocusNode!
arduino
class ACE_EXPORT FocusableElement final : public SoleChildElement, public FocusGroup {
DECLARE_ACE_TYPE(FocusableElement, SoleChildElement, FocusGroup);
绑定过程其实就是绑定FocusNode与点击事件
触发过程
结束了上面绑定过程,那么肯定还有触发这一过程,当发生点击事件时,会由PipelineContext进行事件的分发,PipelineContext的上层就是具体的系统容器了
ini
bool PipelineContext::OnKeyEvent(const KeyEvent& event)
{
CHECK_RUN_ON(UI);
if (!rootElement_) {
LOGE("the root element is nullptr");
EventReport::SendAppStartException(AppStartExcepType::PIPELINE_CONTEXT_ERR);
return false;
}
rootElement_->HandleSpecifiedKey(event);
SetShortcutKey(event);
pressedKeyCodes = event.pressedCodes;
isKeyCtrlPressed_ = !pressedKeyCodes.empty() && (pressedKeyCodes.back() == KeyCode::KEY_CTRL_LEFT ||
pressedKeyCodes.back() == KeyCode::KEY_CTRL_RIGHT);
if ((event.code == KeyCode::KEY_CTRL_LEFT || event.code == KeyCode::KEY_CTRL_RIGHT) &&
event.action == KeyAction::UP) {
if (isOnScrollZoomEvent_) {
zoomEventA_.type = TouchType::UP;
zoomEventB_.type = TouchType::UP;
LOGI("Send TouchEventA(%{public}f, %{public}f, %{public}zu)", zoomEventA_.x, zoomEventA_.y,
zoomEventA_.type);
OnTouchEvent(zoomEventA_);
LOGI("Send TouchEventB(%{public}f, %{public}f, %{public}zu)", zoomEventB_.x, zoomEventB_.y,
zoomEventB_.type);
OnTouchEvent(zoomEventB_);
OnKeyEvent经过事件分发后,最终会分发到对应的FocusNode节点(包括FocusNode 与FocusGroup),之后就执行了我们上文注册阶段说的callback了。至此完成点击与响应的闭环
arduino
bool FocusNode::OnClick(const KeyEvent& event)
{
if (onClickEventCallback_) {
... 触发回调
onClickEventCallback_(info);
return true;
}
return false;
}
触发过程:其实就是触发FocusNode的点击事件
后续
我们可以通过官网的文档OpenHarmony 4.1,后续更多的父子点击控制的新API,其实最终也会基于我们讲到的TouchTest实现
总结
通过本文我们将了解到ArkUI中命中测试的基础流程,通过命中测试我们知道ArkUIEngine是如何收集RenderNode的响应事件,同时我们也学习到了整个Engine中点击处理相关ClickRecognizer
类,以及点击事件注册FocusNode
。从ArkUIEngine的事件封装来看,我们可以看到整个框架架构扩展性还是比较好的,通过OnTouchTestHit方法能让不同的控件实现自己的特殊逻辑响应。