Flutter事件传递与手势识别

Flutter的事件源

Flutter的原始事件是由window中 PointerDataPacketCallback(PointerDataPacket packet) 回调获得的,这个回调再GestureBinding初始化中就设置了window.onPointerDataPacket = _handlePointerDataPacket,我们看一下_handlePointerDataPacket的代码

void _handlePointerDataPacket(ui.PointerDataPacket packet) {
  //_pendingPointerEvents是一个PointerEvent的队列,这段代码的意思是将PointerDataPacket转换成PointerEvent然后存在队列中
  _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
  if (!locked)
    _flushPointerEventQueue();
}

//对队列中的PointerEvent进行出队并依次处理
void _flushPointerEventQueue() {
  assert(!locked);
  while (_pendingPointerEvents.isNotEmpty)
    _handlePointerEvent(_pendingPointerEvents.removeFirst());//_handlePointerEvent对每一个PointerEvent进行处理
}

_handlePointerEvent方法才是对每个PointerEvent进行处理的地方

void _handlePointerEvent(PointerEvent event) {
  HitTestResult hitTestResult;
  if (event is PointerDownEvent) {
    assert(!_hitTests.containsKey(event.pointer));
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);//hitTest方法,来确定命中测试的结果
    _hitTests[event.pointer] = hitTestResult;//event.pointer是每次连续的PointEvent的唯一id,以id为key将hitTestResult存到_hitTests中
  } else if (event is PointerUpEvent || event is PointerCancelEvent) {
    hitTestResult = _hitTests.remove(event.pointer);//事件结束标记,将hitTestResult从_hitTests取出并移除
  } else if (event.down) {
    hitTestResult = _hitTests[event.pointer];//move事件直接重用down事件的hitTestResult,避免每次都进行命中测试
  }
  if (hitTestResult != null ||
      event is PointerHoverEvent ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);//分发Event
  }
}

_handlePointerEvent中比较重要的两点:

  1. 在PointerDownEvent事件时,通过hitTest方法来计算出HitTestResult
  2. 对事件序列通过dispatchEvent(event, hitTestResult)进行分发事件

上面的过程就是将原始事件转换成我们需要的PointEvent,然后再确定命中测试结果,最后再进行分发事件。

确定HitTestResult

HitTestResult中有一个List<HitTestEntry> _path的字段,由多个HitTestEntry来组成的path(事件进行冒泡的路径,为什么是通过冒泡后面会有解释),HitTestEntry是每一个命中的入口,它只有一个HitTestTarget target字段,而HitTestTarget又是由RenderObject来实现的,所以HitTestResult其实就是一系列通过命中测试的RenderObject的集合。我们来看看是如何来确定命中测试的结果的

void hitTest(HitTestResult result, Offset position) {
  assert(renderView != null);
  renderView.hitTest(result, position: position);
  super.hitTest(result, position);//这里是调用的GestureBinding中的hitTest方法,将WidgetsFlutterBinding加入到最后面
}

调用renderView的hitTest方法,继续跟进

bool hitTest(HitTestResult result, { Offset position }) {
  if (child != null)
    child.hitTest(result, position: position);
  result.add(HitTestEntry(this));
  return true;
}

可以看到renderView并没有直接实现HitTestable中的hitTest方法,renderView的hitTest方法中的{ Offset position }是一个可选参数,并且带一个bool类型的返回值,renderView的hitTest方法显示对child进行命中测试,让后再将自己添加到命中测试结果。

RenderObject中并没有发现hitTest方法,但是再其子类RenderBox中发现了名为hitTest的方法,也没有直接实现HitTestable中的hitTest方法,{ Offset position }也是一个可选参数,也有一个bool类型的返回值

bool hitTest(HitTestResult result, { @required Offset position }) {
  if (_size.contains(position)) {//确定hit的位置再自己的size范围里面
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      //先对children进行hitTest,然后再对自己进行hitTest,有一项返回true才能将自己添加到HitTestResult里面
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

我们看一个比较简单的例子,RenderPadding中是怎样对children进行命中测试的,RenderPadding的hitTestChildren实现在RenderShiftedBox中,hitTestSelf的实现在RenderBox中

@override
bool hitTestChildren(HitTestResult result, { Offset position }) {
  if (child != null) {
    final BoxParentData childParentData = child.parentData;
    return child.hitTest(result, position: position - childParentData.offset);//将点击点减去偏移应用到child的命中测试
  }
  return false;
}

@protected
bool hitTestSelf(Offset position) => false;//自己进行命中测试

RenderPadding的命中测试结果就是如果child命中测试成功,则自己也会被添加的命中测试结果中,否则就不对自己进行命中测试

分发Event

接下来就是对Event的分发了,我们直接看GestureBinding中的dispatchEvent方法

@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
  assert(!locked);
  //没有命中测试信息意味着PointerEvent是Hover,Added,Removed其中一种
  if (hitTestResult == null) {
    assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);
    try {
      //将事件分发到注册了此次事件的路由,一般是由GestureRecognizer中void addPointer(PointerDownEvent event)方法进行注册
      pointerRouter.route(event);
    } catch (exception, stack) {}
    return;//进行路由分发直接返回
  }
  //对命中测试的结果进行遍历,应为是先对child进行命中测试,所以事件的序列是冒泡向上传递的
  for (HitTestEntry entry in hitTestResult.path) {
    try {
      //调用target的handleEvent方法处理事件
      entry.target.handleEvent(event, entry);
    } catch (exception, stack) {}
  }
}

当命中测试结果为空时进行路由分发,当命中测试结果不为空时,就进行命中结果分发,handleEvent方法的实现我们来看一个比较典型的,RenderPointerListener中的handleEvent,RenderPointerListener时Listener(可以监听原始PointEvent的Widget)对应的RenderObject对象

@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
  assert(debugHandleEvent(event, entry));
  // onPointerEnter, onPointerHover, and onPointerExit events都在MouseTracker里面处理
  if (onPointerDown != null && event is PointerDownEvent)
    return onPointerDown(event);
  if (onPointerMove != null && event is PointerMoveEvent)
    return onPointerMove(event);
  if (onPointerUp != null && event is PointerUpEvent)
    return onPointerUp(event);
  if (onPointerCancel != null && event is PointerCancelEvent)
    return onPointerCancel(event);
}

RenderPointerListener直接把事件给到对应的回调,大多数RenderObject都没有实现handleEvent方法。

事件监听

Flutter的官方文档推荐我们使用GestureDetector来检测用户手势输入,GestureDetector帮我们区别了各种类型的手势,我们只需要设置需要监听的手势回调就可以了,使用非常方便。
从我们上面的分析可以看到,事件的产生与分发,我们来看一下GestureDetector是如何监听事件并进行区别手势的呢?

@override
Widget build(BuildContext context) {
  final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
    ...省略若干代码
  return RawGestureDetector(
    gestures: gestures,
    behavior: behavior,
    excludeFromSemantics: excludeFromSemantics,
    child: child,
  );
}

可以看到GestureDetector是通过RawGestureDetector来实现的,我们再看RawGestureDetector

@override
Widget build(BuildContext context) {
  Widget result = Listener(
    onPointerDown: _handlePointerDown,
    behavior: widget.behavior ?? _defaultBehavior,
    child: widget.child
  );
  if (!widget.excludeFromSemantics)
    result = _GestureSemantics(owner: this, child: result);
  return result;
}

而RawGestureDetector又是通过Listener来实现的,上面我们知道Listener是监听初始事件PointerEvent的,那他是如何被区别为各种各样的手势的呢?

手势识别

看一下RawGestureDetector中的_handlePointerDown方法

void _handlePointerDown(PointerDownEvent event) {
  assert(_recognizers != null);
  for (GestureRecognizer recognizer in _recognizers.values)
    recognizer.addPointer(event);
}

在PointerDownEvent的时候,将所有recognizer进行addPointer(event),继续跟进addPointer方法,在GestureRecognizer中是空实现,我们先看一个简单的实现TapGestureRecognizer

@override
void addPointer(PointerDownEvent event) {
  startTrackingPointer(event.pointer);//开始追踪id为pointer的事件序列
  if (state == GestureRecognizerState.ready) {
    state = GestureRecognizerState.possible;
    primaryPointer = event.pointer;
    initialPosition = event.position;
    if (deadline != null)
      _timer = Timer(deadline, didExceedDeadline);
  }
}

addPointer中最主要的方法就是startTrackingPointer方法,这个方法是OneSequenceGestureRecognizer中的,可以让OneSequenceGestureRecognizer去追踪这个事件序列,具体分析在下面手势竞技中再讲,Recognizer追踪了这个事件序列后,这个事件的后续事件都会被这个Recognizer处理,会触发handleEvent方法,通过一系列判断会走到handlePrimaryPointer方法,然后再PointerUpEvent时触发
相关回调

@override
void handlePrimaryPointer(PointerEvent event) {
  if (event is PointerUpEvent) {
    _finalPosition = event.position;
    _checkUp();
  } else if (event is PointerCancelEvent) {
    _reset();
  }
}

void _checkUp() {
  if (_wonArenaForPrimaryPointer && _finalPosition != null) {
    resolve(GestureDisposition.accepted);
    if (!_wonArenaForPrimaryPointer || _finalPosition == null) {
      return;
    }
    if (onTapUp != null)
      invokeCallback<void>('onTapUp', () { onTapUp(TapUpDetails(globalPosition: _finalPosition)); });//触发onTapUp回调
    if (onTap != null)
      invokeCallback<void>('onTap', onTap);//触发onTap回调
    _reset();
  }
}

Recognizer会对事件进行分析,然后会去区别不同的情况去触发不同的回调。

如果一个事件序列被多个Recognizer追踪,比如需要监听用户点击与滑动,那么怎么去区别用户到底是点击还是滑动呢?

手势竞技

我们先看一下Recognizer是如何追踪事件序列的,先看startTrackingPointer方法

@protected
void startTrackingPointer(int pointer) {
  GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent);
  _trackedPointers.add(pointer);
  assert(!_entries.containsValue(pointer));
  _entries[pointer] = _addPointerToArena(pointer);
}
  1. GestureBinding(实例是WidgetsFlutterBinding)中的PointerRouter,其实就是维护了一个Map<int,LinkedHashSet<PointerRoute>> routerMap(路由表)的属性,PointerRoute其实就是void Function(PointerEvent event)类型的回调。从上面可以看到,将pointer作为key,handleEvent方法作为值传入。
  2. _trackedPointers是一个HashSet<int>,记录此Recognizer追踪的事件序列
  3. _entries是一个Map<int, GestureArenaEntry>,GestureArenaEntry中包含一个GestureArenaManager(手势竞技管理类)、_pointer(事件id)、GestureArenaMember(GestureRecognizer的基类,其实就是Recognizer本身)

继续看一下_addPointerToArena方法

GestureArenaEntry _addPointerToArena(int pointer) {
  if (_team != null)
    return _team.add(pointer, this);
  return GestureBinding.instance.gestureArena.add(pointer, this);
}

可以看到GestureBinding(实例是WidgetsFlutterBinding)中的gestureArena字段(它是GestureArenaManager)将这个Recognizer添加进去。看看其add方法

final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};

GestureArenaEntry add(int pointer, GestureArenaMember member) {
  //_GestureArena中是一个GestureArenaMember的list,其实就是一个手势id,对应多个GestureArenaMember
  final _GestureArena state = _arenas.putIfAbsent(pointer, () {
    return _GestureArena();
  });
  state.add(member);//将GestureArenaMember添加到_GestureArena中
  return GestureArenaEntry._(this, pointer, member);//再返回一个GestureArenaEntry对象给Recognizer中entries持有
}

从以上的分析可以总结一下startTrackingPointer主要做的事情:

  1. 跟据手势的id(pointer)来添加路由,此Recognizer就可以接受处理余下的事件序列,当有余下事件序列发送过来就会调用此Recognizer中的handleEvent方法(此功能由GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent)代码实现)
  2. 将Recognizer添加到手势竞技场,处理同一个手势id的Recognizer将被添加到同一个_GestureArena(手势竞技场)中,(此功能由_addPointerToArena方法实现)

上面只是添加到路由以及竞技场,但是我们还不知道是事件是怎样被发送到指定的路由,以及多个手势识别器是如何竞争处理手势事件的

事件路由到指定Recognizer

我们知道事件的传递是通过冒泡来进行传递的,HitTestResult的最上层是WidgetsFlutterBinding,最后处理事件的应该在GestureBinding中,我们看一下GestureBinding的handleEvent方法

@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
  pointerRouter.route(event);//调用PointerRouter的route方法将事件进行路由
  ...省略
}

GestureBinding的dispatchEvent方法在HitTestResult为null的时候才路由,主要也就是Hover,Added,Removed这三种事件进行路由,而处理这三个的是一个global的MouseTracker。而此处路由会处理所有注册到routerMap中的Recognizer(实际上只是其handleEvent方法)。

多个Recognizer竞技

还是看GestureBinding的handleEvent方法

@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  }
}

通过pointerRouter路由之后,在PointerDownEvent时候就调用GestureArenaManager的close方法(防止其他Recognizer加入到手势竞技中),所以在就是为什么addPoint注册路由的方法需要PointerDownEvent作为参数了,一旦在down的时候不注册,那么这个事件就与你的Recognizer无关了。看一下close方法

void close(int pointer) {
  final _GestureArena state = _arenas[pointer];
  if (state == null)
    return;
  state.isOpen = false;
  _tryToResolveArena(pointer, state);
}

先将isOpen变为false(在add的时候首先就会判断isOpen),然后调用_tryToResolveArena方法,继续跟进

void _tryToResolveArena(int pointer, _GestureArena state) {
  assert(_arenas[pointer] == state);
  assert(!state.isOpen);
  if (state.members.length == 1) {//只有1个Recognizer,直接添加一个_resolveByDefault方法调用的task
    scheduleMicrotask(() => _resolveByDefault(pointer, state));
  } else if (state.members.isEmpty) {//没有Recognizer,直接移除该pointer对应的_GestureArena
    _arenas.remove(pointer);
  } else if (state.eagerWinner != null) {//渴望的胜利者,在_GestureArena关闭的时候如果不为空会直接作为胜利者
    _resolveInFavorOf(pointer, state, state.eagerWinner);//确定胜利者
  }
}

void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
  assert(state == _arenas[pointer]);
  assert(state != null);
  assert(state.eagerWinner == null || state.eagerWinner == member);
  assert(!state.isOpen);
  _arenas.remove(pointer);//移除竞技场,已经得出结果不需要了
  for (GestureArenaMember rejectedMember in state.members) {
    if (rejectedMember != member)
      rejectedMember.rejectGesture(pointer);//将不是胜利者的GestureArenaMember全部调用拒绝手势
  }
  member.acceptGesture(pointer);//调用胜利这的接受手势
}

但是我们在没有eagerWinner的时候是怎样来竞技的呢?我们用两个具体的手势识别器点击(TapGestureRecognizer)、滑动(PanGestureRecognizer)来分析

首先我们滑动一下

经过上面的分析,在down的时候是解析不出胜利者的,后续move事件会路由给TapGestureRecognizer,PanGestureRecognizer,我们需要看一下TapGestureRecognizer的handleEvent方法

@override
void handleEvent(PointerEvent event) {
  assert(state != GestureRecognizerState.ready);
  if (event.pointer == primaryPointer) {
    //接受手势前滑动距离是否溢出容忍值
    final bool isPreAcceptSlopPastTolerance =
        state == GestureRecognizerState.possible &&
        preAcceptSlopTolerance != null &&
        _getDistance(event) > preAcceptSlopTolerance;
    //接受手势后滑动距离是否溢出容忍值
    final bool isPostAcceptSlopPastTolerance =
        state == GestureRecognizerState.accepted &&
        postAcceptSlopTolerance != null &&
        _getDistance(event) > postAcceptSlopTolerance;
    //move事件下,超出容忍值
    if (event is PointerMoveEvent && (isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) {
      resolve(GestureDisposition.rejected);//拒绝后续事件
      stopTrackingPointer(primaryPointer);//结束追踪事件
    } else {
      handlePrimaryPointer(event);
    }
  }
  stopTrackingIfPointerNoLongerDown(event);
}

所以但凡我们滑动的距离超出了容忍值(这个值是根据经验事件来确定的值),都会拒绝事件结束追踪。所以事件就会落到PanGestureRecognizer身上。

我们再点一下

我们需要看一下PanGestureRecognizer的handleEvent方法

@override
void handleEvent(PointerEvent event) {
  assert(_state != _DragState.ready);
  if (!event.synthesized
      && (event is PointerDownEvent || event is PointerMoveEvent)) {
    final VelocityTracker tracker = _velocityTrackers[event.pointer];
    assert(tracker != null);
    tracker.addPosition(event.timeStamp, event.position);
  }

  if (event is PointerMoveEvent) {
    final Offset delta = event.delta;
    if (_state == _DragState.accepted) {
      if (onUpdate != null) {
        invokeCallback<void>('onUpdate', () => onUpdate(DragUpdateDetails(
          sourceTimeStamp: event.timeStamp,
          delta: _getDeltaForDetails(delta),
          primaryDelta: _getPrimaryValueFromOffset(delta),
          globalPosition: event.position,
        )));
      }
    } else {//move事件,没有接受事件时
      _pendingDragOffset += delta;
      _lastPendingEventTimestamp = event.timeStamp;
      //判断是否由足够的滑动距离来接受,也就是说滑动距离超过一定距离会主动接受
      if (_hasSufficientPendingDragDeltaToAccept)
        resolve(GestureDisposition.accepted);
    }
  }
  stopTrackingIfPointerNoLongerDown(event);//up事件的时候会停止追踪
}

由于我们是点一下,那么距离不够是不会去主动接受的,等经过一系列move事件结束后PanGestureRecognizer还是没有获得事件,最后再up的时候就停止追踪事件了,那么事件就会落到TapGestureRecognizer身上。

通过上面两种情况的分析,不同的Recognizer都有自己的逻辑去接受、拒绝、停止追踪事件。

接受、拒绝、停止追踪事件

通过resolve方法传入一个GestureDisposition可以让Recognizer来处置事件,我们跟进resolve方法看一下具体操作,

void resolve(GestureDisposition disposition) {
  final List<GestureArenaEntry> localEntries = List<GestureArenaEntry>.from(_entries.values);
  _entries.clear();
  for (GestureArenaEntry entry in localEntries)
    entry.resolve(disposition);
}

void resolve(GestureDisposition disposition) {
  _arena._resolve(_pointer, _member, disposition);
}

void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {
  final _GestureArena state = _arenas[pointer];
  if (state == null)
    return; 
  assert(state.members.contains(member));
  if (disposition == GestureDisposition.rejected) {
    state.members.remove(member);//移除一个Recognizer
    member.rejectGesture(pointer);//调用其rejectGesture方法
    if (!state.isOpen)
      _tryToResolveArena(pointer, state);//尝试确定胜利者
  } else {
    if (state.isOpen) {
      state.eagerWinner ??= member;
    } else {
      _resolveInFavorOf(pointer, state, member);//确定胜利者
    }
  }
}

可以看到如果是接受,就直接确认胜利者,如果是拒绝,就将其踢出并尝试确认胜利者。再看一下stopTrackingPointer的具体操作

void stopTrackingPointer(int pointer) {
  if (_trackedPointers.contains(pointer)) {
    GestureBinding.instance.pointerRouter.removeRoute(pointer, handleEvent);//移除路由,不处理余下事件
    _trackedPointers.remove(pointer);//从_trackedPointers中移除
    if (_trackedPointers.isEmpty)
      didStopTrackingLastPointer(pointer);
  }
}

主要就是移除路由,didStopTrackingLastPointer是在没有追踪的PointEvent时,做一些收尾工作,具体都有不同实现。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • 在开发过程中,大家或多或少的都会碰到令人头疼的手势冲突问题,正好前两天碰到一个类似的bug,于是借着这个机会了解了...
    闫仕伟阅读 5,305评论 2 23
  • 触摸事件的生命周期 当我们手指触碰屏幕的那一刻,一个触摸事件便产生了。经过进程间通信,触摸事件被传递到合适的应用之...
    Gintok阅读 1,344评论 0 3
  • 在iOS开发中经常会涉及到触摸事件。本想自己总结一下,但是遇到了这篇文章,感觉总结的已经很到位,特此转载。作者:L...
    WQ_UESTC阅读 5,995评论 4 26
  • 本文主要讲解iOS触摸事件的一系列机制,涉及的问题大致包括: 触摸事件由触屏生成后如何传递到当前应用? 应用接收触...
    baihualinxin阅读 1,203评论 0 9
  • 1.写作缘起 在触摸事件传递机制这个的问题上连自己都觉着不就是老掉牙的Hit-Testingt么,递归遍历,找到最...
    大爱无言阅读 10,461评论 13 57